Skip to content

fix: preserve introspection metadata + coroutine-function detection (#43, #44)#81

Merged
toloco merged 2 commits into
masterfrom
fix/43-44-introspection
Jun 18, 2026
Merged

fix: preserve introspection metadata + coroutine-function detection (#43, #44)#81
toloco merged 2 commits into
masterfrom
fix/43-44-introspection

Conversation

@toloco

@toloco toloco commented Jun 18, 2026

Copy link
Copy Markdown
Owner

Closes #43
Closes #44

Two related introspection bugs in the decorator, fixed together.

#43 — sync functions lose all introspection metadata

The sync path returned the raw Rust CachedFunction/SharedCachedFunction directly, which copied no metadata: __name__/__qualname__/__module__/__doc__/__wrapped__ were missing and inspect.signature() collapsed to (*args, **kwargs). This breaks logging, documentation tools, and signature-introspecting frameworks (FastAPI, click) — and was inconsistent with the async wrapper, which already set these.

Fix: give both pyclasses a __dict__ (#[pyclass(frozen, dict)]) and apply functools.wraps(fn) to the returned object in the sync path. The Rust object is still returned directly, so __call__ stays a single FFI crossing (no Python wrapper on the hot path) — the __dict__ is touched only at decoration time. inspect.signature resolves through __wrapped__.

#44 — async function not detected as a coroutine function

AsyncCachedFunction is a plain class with an async def __call__, but the instance wasn't flagged, so inspect.iscoroutinefunction/asyncio.iscoroutinefunction returned False while calling it returned a coroutine. Frameworks branching on iscoroutinefunction (FastAPI/Starlette, anyio, pytest-asyncio) treated it as sync and dropped the returned coroutine ("coroutine was never awaited").

Fix: mark the wrapper in __init__inspect.markcoroutinefunction(self) on Python 3.12+, else set the asyncio.coroutines._is_coroutine sentinel on 3.10/3.11 (the only pre-3.12 mechanism asyncio.iscoroutinefunction recognizes).

Test — tests/test_introspection.py

  • sync (memory and shared): __name__/__qualname__/__module__/__doc__/__wrapped__ correct, inspect.signature == (a, b=2), and caching still works after wrapping.
  • async: name/doc/signature intact; detected as a coroutine function and still awaitable.

Verified fail-before → pass-after by stashing the fixes: the three targeted tests failed (missing metadata / iscoroutinefunction False); the async-introspection test already passed pre-fix (that path always set its dunders). The coroutine-detection test passes on 3.10–3.13, exercising both the markcoroutinefunction (3.12+) and sentinel (3.10/3.11) paths.

Gates run (risky — PyO3 boundary #[pyclass] change; #44 is Python-version-dependent)

  • make fmt / make lint (ruff, ty, clippy -D warnings) ✓
  • make testcargo test (11) + pytest (106, +4 new) ✓
  • make test-matrix — Python 3.10–3.13 ✓ (3.14 skipped locally via the documented uv-resolves-stale-alpha guard; CI covers 3.14 final)
  • make bench — no regression: the dict flag doesn't touch __call__; memory ~18.2M ops/s, single-thread 17.5–21.8M

No public API surface change (same decorator/methods), so no README/docs updates.

🤖 Generated with Claude Code

toloco and others added 2 commits June 18, 2026 10:36
…#43, #44)

#43: sync decorated functions returned the raw Rust object with no metadata —
__name__/__qualname__/__module__/__doc__/__wrapped__ missing and
inspect.signature collapsing to (*args, **kwargs), breaking logging, doc
tools, and signature-introspecting frameworks (FastAPI, click). Only the async
wrapper set these, so sync and async were inconsistent.

Give CachedFunction and SharedCachedFunction a __dict__ (`#[pyclass(frozen,
dict)]`) and apply functools.wraps(fn) to the returned object in the sync path.
The Rust object is still returned directly, so __call__ stays a single FFI
crossing (no Python wrapper on the hot path); the __dict__ is touched only at
decoration time. inspect.signature now resolves via __wrapped__.

#44: an async decorated function (AsyncCachedFunction instance with an
`async def __call__`) was not detected by inspect/asyncio.iscoroutinefunction,
so frameworks branching on it (FastAPI/Starlette, anyio, pytest-asyncio)
treated it as sync and dropped the returned coroutine. Mark the wrapper in
__init__: inspect.markcoroutinefunction on 3.12+, else the
asyncio.coroutines._is_coroutine sentinel on 3.10/3.11.

Tests (tests/test_introspection.py): sync (memory + shared) exposes
name/qualname/module/doc/__wrapped__ and a resolvable signature and still
caches; async keeps name/doc/signature and is detected as a coroutine function
(verified across 3.10–3.13, covering both the markcoroutinefunction and
sentinel paths). Verified fail-before -> pass-after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared backend is Unix-only (lib.rs gates it behind not(windows); on
Windows SharedCachedFunction is a stub whose __new__ rejects ttl). CI ignores
the shared-backend test files on Windows, but the new test_introspection.py
isn't in that ignore list, so test_sync_preserves_introspection[shared] ran on
Windows and failed with "unexpected keyword argument 'ttl'". Mark the shared
param skipif win32, matching the convention in the other shared tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@toloco toloco merged commit ec9b842 into master Jun 18, 2026
14 checks passed
@toloco toloco deleted the fix/43-44-introspection branch June 18, 2026 09:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant